在上一篇裡面我們自己動手打造了一個超級簡單的後端程式,但是你有沒有覺得很麻煩?居然要處理這麼多事情,真是沒有天理,為什麼我寫 Rails 的朋友都跟我說他們只要 30 分鐘就可以寫一個 Blog?為什麼可以那麼快?
你有聽過一種東西叫做 Framework 嗎?框架。用起來跟你在用 library(像 jQuery)有一點像,都是用很多別人提供好的套件,只是框架會是一個超級完整的架構,讓你必須照著它的架構去走。所以好處就是如果框架很完整的話,它會把麻煩的事情全部都做掉;但壞處就是有些地方你想客製化的話可能就比較麻煩一點。
今天我們的主角就是 Node.js 拿來寫 server 的框架:Express.js。
(有另外一套號稱自己是:next generation web framework for node.js
的框架koa,那為什麼不教這個呢?因為我只會 Express...)
廢話不多說,立刻帶你來看一下這個框架到底可以幹嘛。我們先來建置一下開發環境:
npm install express --save
然後開一個 index.js
,裡面寫
var express = require('express');
var app = express();
app.get('/', function (req, res) {
res.send('hello world');
})
app.get('/hello', function (req, res) {
res.send('你好');
})
app.get('/hello/:name', function (req, res) {
res.send('你好, ' + req.params.name);
})
app.listen(3000, function () {
console.log('Example app listening on port 3000!')
})
執行 node index.js
之後會發現輸出一行Example app listening on port 3000!
,之後就可以打開http://localhost:3000/hello/peter看一下結果,會發現畫面上寫著你好, peter
上面的程式碼規則滿固定的,你多觀察幾次應該會有一點心得。我們可以針對不同的路徑來寫不同的處理,這就叫做路由( Routing),是後端程式很重要的一個部分。
像是/hello/:name
,前面加個冒號表示這個是在網址上會變動的一個參數,你在程式裡面可以用req.params.name
把這個參數取出來,/hello/peter
就會取到peter
,/hello/efefef
就會取到efefef
。
為什麼說路由是很重要的一個部分呢?因為如果你只有一個網址的話根本不可能做事嘛!你要怎麼分辨這個 request 到底是做什麼的?(其實你硬要也是可以啦,但是會很麻煩就是了)
我們可以來觀察一下 iT 邦的路由是用什麼規則設計的,例如說我個人的文章頁面網址是這樣:
http://ithelp.ithome.com.tw/users/20091346/articles
很明顯看得出來就是/users/:user_id/articles
,代表這個使用者底下的所有文章。如果你爽的話當然也可以用/abc/:user_id/def
來當作網址,但是這樣一點都不可讀,後面接手的工程師一定會想殺了你。為了自己的人身安全,強烈建議你不要這樣做。
再來看導覽列的幾個 tab 的網址:
會發現後面三個其實都是到同一個地方,只是透過後面帶的?tab=TAB_NAME
來區分。有了以上這些資訊之後,我們可以寫一個非常非常簡單版本的仿 iT 邦的路由出來。
var express = require('express');
var app = express();
app.get('/', function (req, res) {
res.send('index');
})
app.get('/users/:userId/articles', function (req, res) {
res.send('這是 user: ' + req.params.userId + ' 的文章');
})
app.get('/questions', function (req, res) {
res.send('這是問答頁面');
})
app.get('/articles', function (req, res) {
res.send('這是文章列表,你想看的 tab 是:' + req.query.tab);
})
app.listen(3000, function () {
console.log('Example app listening on port 3000!')
})
你可以試著開開看以下幾個頁面看會輸出什麼(或是你已經猜的到其實也可以不用開)
總之,我們用這麼短短的幾行就把東西都搞定了,可以針對不同的 URL 輸出不同的資訊。可是一直到目前為止,我們都是輸出這些文字而已,有沒有可能輸出 HTML 呢?這樣看起來就更像一個完整的網頁了!我就可以拿去跟我同學炫耀說:嘿你看,我會寫網頁了喔!而且真的可以動!
這個當然可以,而且非常簡單,你看看下面這段程式碼:
var express = require('express');
var app = express();
app.get('/users/:name', function (req, res) {
res.send(
'<h2> Hello, ' + req.params.name + '</h2>'
);
})
app.listen(3000, function () {
console.log('Example app listening on port 3000!')
})
我們只要把 send
裡面的東西變成 HTML 的格式就行了,就能夠輸出網頁。而且因為瀏覽器很智慧,所以就算你沒有輸出<html>
, <body>
這些標籤,它也能夠自動幫你加上。不過這邊只是為了方便還有我懶得打那麼多字,實務上仍然會自己加上這些必要的標籤。
不過,你有沒有覺得有點麻煩?你明明就已經寫好一份 HTML 了,差別只在於傳進來的資料不一樣所以要做一些改動,你就要把 HTML 都加上一堆單引號跟加號變成字串,然後這樣一直拼接起來,有夠累!有沒有更方便的方法?
還記得我以前說過,我做很多事的時候都會先想到:「我最後想要使用的方式」是什麼,先想出結果,再一步步實作出來。我是不知道你想的是怎樣,但我想的差不多是這樣:
app.get('/users/:name', function (req, res) {
res.send(render('index.html', {
name: req.params.name
}));
})
指定你要渲染的檔案跟參數,render
這個函式就會自己幫你把index.html
跟你想要傳的參數name
結合在一起,變成我們想輸出的樣子。那因為要讓程式知道index.html
到底要替換哪一個部分,所以你的index.html
當然也要做一些修改
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<h2>{name}</h2>
</body>
</html>
其實你仔細想想,會發現我們要實做的render
函式其實也不難,不就是讀檔案然後替換掉固定格式的字串而已嗎?
function render(filename, params) {
var data = fs.readFileSync(filename, 'utf8');
for (var key in params) {
data = data.replace('{' + key + '}', params[key]);
}
return data;
}
附上完整程式碼:
var express = require('express');
var app = express();
var fs = require('fs');
function render(filename, params) {
var data = fs.readFileSync(filename, 'utf8');
for (var key in params) {
data = data.replace('{' + key + '}', params[key]);
}
return data;
}
app.get('/users/:name', function (req, res) {
res.send(render('index.html', {
name: req.params.name
}));
})
app.listen(3000, function () {
console.log('Example app listening on port 3000!')
})
這樣就達成我們的目的了,不用在那邊自己拼裝字串了,只要簡單的改一下 HTML 就好。
話說,來一段題外話,我剛剛在寫那段讀檔案的時候原本是用了非同步的寫法,就是必須使用 callback 去接收結果
function render(filename, params) {
fs.readFile(filename, 'utf8', function (err, data) {
if (err) return console.log(err);
console.log(data);
for (var key in params) {
data = data.replace('{' + key + '}', params[key]);
}
return data;
});
}
然後發現這樣子不行,為什麼呢?因為那個return data;
那一句是在 callback function 裡面,所以不是render
這個函式的 return,因此render
根本不會回傳任何東西。這個是很多新手在寫 JavaScript 的時候會碰到的錯誤,你只要熟記一個原則就好:「非同步的操作,一律用 callback 把結果帶回去」。像以上這種情況,除了要改成用 callback 以外,呼叫 render 的地方也要修改:
function render(filename, params, callback) {
fs.readFile(filename, 'utf8', function (err, data) {
if (err) return cabllack(err);
for (var key in params) {
data = data.replace('{' + key + '}', params[key]);
}
callback(null, data); // 用 callback 傳回結果
});
}
app.get('/users/:name', function (req, res) {
render('index.html', {
name: req.params.name
}, function (err, data) {
res.send(data); // 這邊要寫一個 function 才能接收到資料
});
})
如果上面這段看不懂,那你需要去複習一下 callback function。
好,接著讓我們回歸正題,我們剛剛已經自己寫了一個 render
的 function 對吧,但是現在更難的一個問題來了。假設我是要輸出一個列表怎麼辦?例如說我傳進去的資料是[1, 2, 3]
,我想輸出的內容是:
<li>1</li>
<li>2</li>
<li>3</li>
這個需求其實超級普遍,例如說部落格的文章列表。你就會從資料庫裡面拿到每一筆文章的資料結合成一個陣列,然後必須用 HTML 顯示出來。每一個 row 的格式都一模一樣,差別就在於資料不同而已。這個又要怎麼辦呢?
很簡單,還記得我們說過框架很完整嗎?其實框架都幫你把這些問題解掉了啦!包含上面那個{name}
的範例也是,有現成的解決方案可以用。俗話說:「不要自已動手造輪子」大概就是這樣的意思。如果有現成的解決方案先拿來用用看,真的不行再自己寫一個。
那為什麼我還要讓你手動自已寫render
這個函式?我只是想讓你知道我們待會要講的東西的基本原理而已,這樣你用起來才會更有感覺,才會知道為什麼我們需要這個東西。
這台機車引擎壞掉了卻還可以發動,一定是另有隱情(from joke 板某篇笑話)
模板這種東西就跟我們剛剛的 index.html
有八七成像,只是各有各的風格而已,完全看你自己喜歡哪一種。他們的原理就是用自己獨特的格式來寫 HTML,然後傳進資料之後結合在一起,最後 compile 成 HTML。你也可以直接想成是 HTML 版的 SCSS 那種感覺。
第一個要介紹的叫做 Pug,直接來看一下用這個寫 HTML 會長這樣
doctype html
html(lang="en")
head
title= pageTitle
script(type='text/javascript').
if (foo) bar(1 + 5)
body
h1 Pug - node template engine
#container.col
if youAreUsingPug
p You are amazing
else
p Get on it!
p.
Pug is a terse and simple templating language with a
strong focus on performance and powerful features.
嗯,很好,完全不是我喜歡的風格,我們再看下一個
第二個是 EJS,來看一下簡單的範例:
<ul>
<% for(var i=0; i<supplies.length; i++) {%>
<li><%= supplies[i] %></li>
<% } %>
</ul>
很好,我喜歡這個!跟 PHP 也有八七成像,把 HTML 跟程式碼混在一起寫,雖然看起來比較雜亂,但是也更直覺一點
最後一個要介紹的是 Dust.js,是 Linkedin 在用的。
<h1>{title}</h1>
<ul>
{#supplies}
<li>{.}</li>
{/supplies}
</ul>
其實我最習慣用的是這個啦,因為我之前公司用的就是這一套,所以用得比較熟悉,使用的感覺也不錯。不過有些限制滿麻煩的,到後來我覺得最快能夠上手的是ejs
,因為比較簡單,你想在裡面寫什麼程式碼都可以。但缺點當然就是一不小心就會弄的很雜亂。
接著讓我們把 ejs 跟 express 串起來吧!一樣按照慣例先來裝一下該裝的套件
npm install ejs --save
再來我們新開一個資料夾叫做views
,裡面新建一個index.ejs
,內容是這樣:
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<h2><%= name %></h2>
<ul>
<% items.forEach(function(item) { %>
<li><%= item %></li>
<% }); %>
</ul>
</body>
</html>
然後在我們的主程式裡加上設定跟輸出的程式碼:
var express = require('express');
var app = express();
app.set('view engine', 'ejs');
app.get('/users/:name', function (req, res) {
res.render('index', { // 這邊不用寫 views/index 是因為 express 預設 template 就是會放在 views 資料夾裡面
name: req.params.name,
items: ['peter', 'nick', 'cake']
})
})
app.listen(3000, function () {
console.log('Example app listening on port 3000!')
})
打開http://localhost:3000/users/huli之後就可以看到成果了:
是不是覺得其實還滿簡單的?這樣練習下來其實就已經大概知道寫網頁的步驟了
這個就叫做動態網頁,因為每一次你都是根據 request 的內容來動態產生 HTML,你每次的 response 幾乎都會長得不太一樣。相比之下,靜態網頁指的就是只有 HTML,你怎麼輸出都是同一份。
最後讓我們來看一個超級完整的範例,先來看一下資料夾結構跟最後完成的樣子:
.
├── index.js
├── package.json
├── routes
│ ├── articles.js
│ └── users.js
└── views
├── article.ejs
├── articles.ejs
├── header.ejs
├── user.ejs
└── users.ejs
這樣的結構會更利於之後的開發,因為我們把各個路徑要做的事情切到routes
資料夾的檔案底下,這樣會比較好管理。然後views
也多了header.ejs
,是負責顯示網站的導覽列。
先來看一下最重要的index.js
var express = require('express');
var app = express();
var users = require('./routes/users'); //引入檔案
var articles = require('./routes/articles'); //引入檔案
app.set('view engine', 'ejs');
app.use('/users', users); // 把這個路由的東西都交給 user 處理
app.use('/articles', articles);
app.listen(3000, function () {
console.log('Example app listening on port 3000!')
})
再來 users 跟 articles 根本一模一樣的兩個東西,架構完全一樣,只有一些資料不一樣而已。因此我們選一個來看就好,我們看routes/users.js
var express = require('express');
var router = express.Router();
router.get('/', function (req, res) {
var users = [{ //自己隨便寫一些資料
id: 1,
name: 'peter'
}, {
id: 2,
name: 'nick'
}, {
id: 3,
name: '\(o_o)/'
}];
res.render('users', { // 輸出
users: users
});
});
router.get('/:id', function (req, res) {
res.render('user', { //輸出
id: req.params.id
})
});
module.exports = router;
其實就是分成兩個網址,users
跟users/:id
,根據要查詢所有使用者還是單一一個使用者 render 不同的檔案。先來看比較簡單的 views/user.ejs
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" />
</head>
<body>
<% include header %>
<div class="container">
<h2>使用者資料</h2>
<table class="table table-bordered">
<tr>
<th>id</th>
</tr>
<tr>
<td><%= id %></td>
</tr>
</table>
</div>
</body>
</html>
一個很簡單的 table 顯示 id 出來而已。這邊的重點是<% include header %>
,你可以把每一個部分都獨立成一個檔案,再用 include 來引入。這就跟 Node.js 的 require 很像。我們在views/header.ejs
這個檔案主要放的就是一個 bootstrap 的導覽列:
<nav class="navbar navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">管理系統</a>
</div>
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
<ul class="nav navbar-nav">
<li><a href="/articles">文章管理</a></li>
<li><a href="/users">使用者管理</a></li>
</ul>
</div>
</div>
</nav>
最後來看一下輸出使用者列表的views/users.ejs
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" />
</head>
<body>
<% include header %>
<div class="container">
<h2>使用者列表</h2>
<table class="table table-bordered">
<tr>
<th>id</th>
<th>姓名</th>
</tr>
<% users.forEach(function(user) { %>
<tr>
<td><a href="/users/<%= user.id %>"><%= user.id %></a></td>
<td><%= user.name %></td>
</tr>
<% }); %>
</table>
</div>
</body>
</html>
跟之前的差不多,寫一個迴圈然後把 table 的每一列都印出來。因為 articles 的就只是把文字跟參數改一改,所以程式碼就不附在這邊了。等這個系列成功寫完之後,我會再整理一下包含範例程式碼全部丟到 github 上,可以等那個時候再來看完整的範例。
這篇好像不小心寫太多,終於來到總結的時候。我決定用一些問題來幫大家做個整理,你可以自己先想想看這些問題:
因為我把我的回答寫在這篇的話,你就不會思考而是會直接卷下去看答案,所以我決定下一章再來跟大家討論這些問題。
今天的部分就到邊結束囉